iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0

今天要做什麼?

昨天我們學會了參數化測試,用優雅的方式處理大量測試資料。今天要解決一個新挑戰:「如何測試依賴外部服務的程式碼?」

想像你有個寄送通知的功能,它會真的寄出 email。測試時,你不希望真的寄信出去。這時候就需要「測試替身」來幫忙了!

學習目標

今天結束後,你將學會:

  • 理解測試替身的概念與種類
  • 掌握 unittest.mock 的用法
  • 學會 Stub、Mock、Spy 的使用場景
  • 掌握測試替身的最佳實踐

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期
├── Day 06 - 參數化測試
├── Day 07 - 測試替身基礎 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試替身? 🎪

測試替身(Test Double)就像電影中的替身演員,在測試時代替真實的依賴物件。

三種主要類型

  1. Stub(存根) - 提供固定回應

    • 像是自動販賣機:投錢就給飲料
    • 不在乎被呼叫幾次
  2. Mock(模擬) - 驗證互動行為

    • 像是考官:檢查你有沒有做對步驟
    • 會驗證方法是否被正確呼叫
  3. Spy(間諜) - 監控真實行為

    • 像是監視器:記錄發生了什麼事
    • 保留原始功能,同時記錄呼叫

實戰演練 🚀

範例 1:使用 Mock 測試通知服務

先建立一個簡單的 EmailService 和 NotificationService。

建立 src/services/email_service.py

class EmailService:
    def send(self, to: str, subject: str, body: str) -> bool:
        # 實際實作會真的寄信
        print(f"Sending email to {to}")
        return True

建立 src/services/notification_service.py

from src.services.email_service import EmailService

class NotificationService:
    def __init__(self, email_service: EmailService):
        self.email_service = email_service
    
    def notify(self, user_email: str, message: str) -> bool:
        return self.email_service.send(
            user_email,
            'Notification',
            message
        )

建立 tests/day07/test_notification_service.py

from unittest.mock import Mock
from src.services.notification_service import NotificationService

def test_sends_email_when_notifying_user():
    # 建立 Mock
    mock_email_service = Mock()
    mock_email_service.send.return_value = True
    
    notification_service = NotificationService(mock_email_service)
    
    # 執行測試
    result = notification_service.notify('user@example.com', 'Hello!')
    
    # 驗證結果
    assert result is True
    
    # 驗證 Mock 被正確呼叫
    mock_email_service.send.assert_called_once_with(
        'user@example.com',
        'Notification',
        'Hello!'
    )

範例 2:使用 Stub 測試遊戲服務

建立 src/services/random_generator.py

import random

class RandomGenerator:
    def generate(self, min_val: int, max_val: int) -> int:
        return random.randint(min_val, max_val)

建立 src/services/game_service.py

from src.services.random_generator import RandomGenerator

class GameService:
    def __init__(self, random_generator: RandomGenerator):
        self.random_generator = random_generator
    
    def roll_dice(self) -> int:
        return self.random_generator.generate(1, 6)
    
    def is_winning(self, dice_value: int) -> bool:
        return dice_value >= 4

建立 tests/day07/test_game_service.py

from unittest.mock import Mock
from src.services.game_service import GameService

def test_wins_when_dice_value_is_4_or_higher():
    # 建立 Stub - 固定回傳 5
    stub_random_generator = Mock()
    stub_random_generator.generate.return_value = 5
    
    game_service = GameService(stub_random_generator)
    
    dice_value = game_service.roll_dice()
    is_win = game_service.is_winning(dice_value)
    
    assert dice_value == 5
    assert is_win is True

def test_loses_when_dice_value_is_less_than_4():
    # 建立 Stub - 固定回傳 2
    stub_random_generator = Mock()
    stub_random_generator.generate.return_value = 2
    
    game_service = GameService(stub_random_generator)
    
    dice_value = game_service.roll_dice()
    is_win = game_service.is_winning(dice_value)
    
    assert dice_value == 2
    assert is_win is False

範例 3:使用 Spy 監控方法呼叫

建立 src/services/logger.py

class Logger:
    def log(self, message: str) -> None:
        print(f"[LOG] {message}")

建立 src/services/calculator.py

from src.services.logger import Logger

class Calculator:
    def __init__(self, logger: Logger):
        self.logger = logger
    
    def add(self, a: int, b: int) -> int:
        result = a + b
        self.logger.log(f"Adding {a} + {b} = {result}")
        return result
    
    def subtract(self, a: int, b: int) -> int:
        result = a - b
        self.logger.log(f"Subtracting {a} - {b} = {result}")
        return result

建立 tests/day07/test_calculator_with_spy.py

from unittest.mock import Mock, patch
from src.services.calculator import Calculator
from src.services.logger import Logger

def test_logs_calculation_when_adding():
    # 使用 Spy 監控 log 方法
    logger = Logger()
    logger.log = Mock(wraps=logger.log)
    
    calculator = Calculator(logger)
    result = calculator.add(2, 3)
    
    # 驗證計算結果
    assert result == 5
    
    # 驗證 log 被呼叫
    logger.log.assert_called_once_with('Adding 2 + 3 = 5')

def test_logs_calculation_when_subtracting():
    logger = Logger()
    logger.log = Mock(wraps=logger.log)
    
    calculator = Calculator(logger)
    result = calculator.subtract(5, 3)
    
    assert result == 2
    logger.log.assert_called_once_with('Subtracting 5 - 3 = 2')

使用 patch 裝飾器

Python 還提供了 @patch 裝飾器,可以更優雅地建立測試替身:

from unittest.mock import patch
from src.services.notification_service import NotificationService

@patch('src.services.notification_service.EmailService')
def test_notification_with_patch(mock_email_class):
    # 設定 mock 實例
    mock_email_instance = mock_email_class.return_value
    mock_email_instance.send.return_value = True
    
    # 執行測試
    notification_service = NotificationService(mock_email_instance)
    result = notification_service.notify('test@example.com', 'Test message')
    
    assert result is True
    mock_email_instance.send.assert_called_once()

使用時機 🎯

何時用 Stub?

  • 需要固定的測試資料
  • 外部服務的回應不重要
  • 想要控制測試環境

何時用 Mock?

  • 需要驗證方法被呼叫
  • 關心互動的正確性
  • 測試物件之間的協作

何時用 Spy?

  • 想要保留原始行為
  • 需要監控方法呼叫
  • 部分模擬真實物件

最佳實踐 💡

1. 保持測試簡單

# ✅ 好的做法:清楚的測試意圖
def test_sends_notification_email():
    mock_email = Mock()
    mock_email.send.return_value = True
    # ... 簡單明瞭的測試

# ❌ 避免:過度複雜的設置
def test_does_everything():
    # 10 行的 mock 設置...
    pass

2. 一次測一件事

# ✅ 好的做法:專注單一行為
def test_calls_email_service_with_correct_parameters():
    # 只測試參數傳遞
    pass

def test_returns_true_when_email_is_sent_successfully():
    # 只測試回傳值
    pass

3. 適當的驗證

# ✅ 好的做法:驗證重要的互動
mock_service.send.assert_called_once_with(expected_params)

# ❌ 避免:過度驗證
assert mock.method1.call_count == 1
assert mock.method2.call_count == 2
assert mock.method3.call_count == 3
# ... 太多不必要的驗證

今日回顧 📝

今天我們學會了:

測試替身的三種類型

  • Stub:提供固定回應
  • Mock:驗證互動行為
  • Spy:監控真實物件

unittest.mock 測試工具

  • Mock():建立 Mock 物件
  • return_value:設定回傳值
  • assert_called_once_with():驗證呼叫
  • @patch:裝飾器模式

實務應用

  • EmailService 的 Mock 測試
  • GameService 的 Stub 測試
  • Calculator 的 Spy 測試

小練習 🏆

試著為以下 PaymentService 寫測試:

class PaymentService:
    def __init__(self, gateway, logger):
        self.gateway = gateway
        self.logger = logger
    
    def process_payment(self, amount: float) -> bool:
        self.logger.log(f"Processing payment: ${amount}")
        
        if amount <= 0:
            self.logger.log('Invalid amount')
            return False
        
        result = self.gateway.charge(amount)
        self.logger.log(f"Payment result: {'success' if result else 'failed'}")
        
        return result

提示:

  1. Mock PaymentGatewaycharge 方法
  2. Spy Loggerlog 方法
  3. 測試正常付款和無效金額的情況

明天我們將學習「例外處理測試」,了解如何測試錯誤情況! 🚀


上一篇
Day 06 - 參數化測試 🔢
下一篇
Day 08 - 例外處理測試 ⚠️
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言